Skip to content

Batched leaf-mask topology construction for generated conv grids - #757

Open
swahtz wants to merge 4 commits into
openvdb:mainfrom
swahtz:feature/batched-conv-topology-755
Open

Batched leaf-mask topology construction for generated conv grids#757
swahtz wants to merge 4 commits into
openvdb:mainfrom
swahtz:feature/batched-conv-topology-755

Conversation

@swahtz

@swahtz swahtz commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Batched leaf-mask topology construction for generated conv grids

Fixes #755.

Summary

conv_grid / conv_transpose_grid with generated targets (and refined_grid / coarsened_grid / the stride-1 dilate-pad paths generally) built their output one batch member at a time: each member paid a full NanoVDB RefineGrid/CoarsenGrid/DilateGrid/PadGrid build (3+ stream synchronizations each, a host-side speculative root refinement with a pageable D2H readback, a GridHandle constructor with 2 blocking memcpys + raw cudaMalloc/cudaFree), plus a host-built proxy grid + H2D per empty member, and finally nanovdb::cuda::mergeGridHandles added another synchronization per grid. That is ~1.5 ms of fixed overhead x B members x 21 plans per training iteration in generative workloads where topology changes every step (#741 / #753) — construction cost scaled linearly in batch size and left the GPU at ~35% utilization.

This PR batches the leaf-mask topology machinery across the whole grid batch (extending #712's leaf-mask direction — no return to coordinate staging), in four self-contained commits:

1. Batched topology builder + refine/coarsen passes

New src/fvdb/detail/utils/nanovdb/BatchedTopologyBuilder.cuh: one batched pass builds ALL grids at once. Per pass:

  • one emission kernel over all source leaves of all members produces candidate output leaves as (root-tile sort key, in-tile node key, origin, 512-bit mask) slots, segmented per grid — mask bit math is NanoVDB's own (RefineLeafMasksFunctor::refineMask, CoarsenLeafMasksFunctor::coarsenMask);
  • two stable segmented radix sorts put each grid's slots in canonical NanoVDB node order (root tiles by the PointsToGrid offset-shifted key, then x-major upper/lower child offsets), so produced grids enumerate voxels identically to every other build path (pinned by elementwise torch.equal tests);
  • head-flag + scan passes dedup nodes and derive per-grid counts and parent linkage; root tiles are derived on device from the unique upper keys (the host-side speculative refineRoot and its readback disappear);
  • one cudaStreamSynchronize reads back per-grid node counts, one buffer is allocated, and batched kernels (transcribed from tools::cuda::TopologyBuilder's functors with (gridIndex, localIndex) indexing) write every grid's headers (mGridIndex=g, mGridCount=B), nodes, leaf mOffset/mPrefixSum, and bboxes. Empty members become valid empty grids inline.

fineGridHandleFromCoarseCUDA / coarseGridHandleFromFineCUDA route through it for all batch sizes; multi-pass factors (4, 8) chain passes, mirroring the previous per-pass semantics. Checksums are disabled on the output (matching ops::contiguousGridHandle and mergeGridHandles). CPU / PrivateUse1 paths and non-power-of-two coordinate fallbacks unchanged; masked subdivision still prunes upstream, then batched-refines.

2. Batched box-dilate passes (stride-1 K>1, k3s2)

A BoxDilate pass computes the Minkowski sum with an axis-aligned unit box via pure per-axis bit shifts (scatter formulation, up to 27 target leaves per source leaf, deduplicated by the same back-end). This covers DilateGrid's 26-neighbor dilation ([-1,1]^3) and both PadGrid octants ({-1,0}^3, {0,1}^3), so the stride-1 uniform-K conv/conv-transpose paths and the k3s2 transpose (refine + negative pad) become batched pass sequences. The per-member perItemGridHandle drivers are deleted from both conv builders.

3. Identity-plan fast path (issue's secondary item)

ConvolutionPlan.from_grid_batch[_transposed](1, 1, g[, g]) short-circuits to the matmul backend without building tensor-valued transform diagnostics (which are trivially exact when source and target share GridBatchData), preserving channel-pair validation, backend-name rejection, and the general path for distinct-but-equal-looking grids: ~1.2 ms -> 0.08 ms per call.

4. Grid-construction cheap wins

  • makeGridBatchData: leafBatchIndices via one repeat_interleave instead of B x torch::full + torch::cat (B+1 dispatches on every grid construction).
  • voxelSizesTensor / voxelOriginsTensor: accessor fill instead of per-element ATen indexing (6 dispatches per grid per call, on every plan construction's transform validation).

Measurements

RTX PRO 6000 Blackwell, synthetic shell batches (~7.8k voxels/member, resolution 64), median of 20 CUDA-event-timed iterations (src/benchmarks/convolution/benchmark_conv_grid_build.py):

op before B=16 after B=16 before B=48 after B=48
conv_transpose_grid k2s2 9.0 ms 0.80 ms 25.6 ms 0.94 ms
conv_grid k2s2 8.7 ms 0.80 ms 23.4 ms 0.94 ms
conv_grid k3s1 9.8 ms 0.83 ms 25.1 ms 1.00 ms
plan from_grid_batch(2,2,g) 8.9 ms 1.27 ms 24.8 ms 1.62 ms
plan from_grid_batch_transposed(2,2,g) 10.2 ms 1.94 ms 29.4 ms 3.27 ms
4-level plan-pyramid rebuild (8 plans + 3 conv_grids) 114 ms 17.4 ms 309 ms 27.0 ms
identity plan from_grid_batch(1,1,g,g) ~1.2 ms 0.08 ms

Construction cost is now near-flat in batch size (B=1: within noise of the old single-grid path). The issue's per-iteration plan-construction share (~70 ms of a 165 ms shape-VAE iteration at B=16) drops to single-digit milliseconds.

Follow-ups (out of scope, same back-end): an ijk emission front-end to resolve BuildGridFromIjk.cu's per-member FIXME (from_ijk/from_points/shifted-geometry fallbacks), and dilated_grid/BuildPaddedGrid's standalone per-member loops.

Test plan

  • New tests/unit/test_batched_topology_builder.py (18 tests): elementwise (torch.equal on ijk.jdata, num_voxels, per-member bboxes) equivalence against from_ijk-built expected topologies — pinning canonical node order — plus per-member coordinate-set equality against the CPU paths, across: mixed member sizes, empty members (first/middle/last/all), coordinates straddling +-4096 root-tile boundaries and negative octants (where the sort-key and stored Tile::key encodings order differently), single-grid and 16-grid batches, factors 2 and 4, masked refine, conv_grid/conv_transpose_grid K in {2,3,4,5} at stride 1, k3s2 transpose, k2s2 vs per-member, and a refine->coarsen round trip.
  • Existing suites, all green (724 passed, 1 skipped): pytest unit/test_conv_semantics_integration.py unit/test_conv_default.py unit/test_conv_transpose_default.py unit/test_batching.py unit/test_basic_ops.py unit/test_sliced_batch.py unit/test_conv_semantics.py unit/test_conv_ground_truth.py unit/test_nn_modules.py — includes the elementwise ijk order pins, sliced-view coverage, resource-stats path pinning, and the matmul/identity plan contract tests.
  • Benchmark: python src/benchmarks/convolution/benchmark_conv_grid_build.py (numbers above; --gso runs the issue's verbatim GSO repro).

🤖 Generated with Claude Code

swahtz and others added 4 commits September 1, 2026 21:12
Generated-topology grid construction built one NanoVDB grid per batch member
serially (3+ stream syncs per member inside RefineGrid/CoarsenGrid, a host-side
speculative root refinement readback, per-member GridHandle constructor blocking
copies, a host proxy grid + H2D per empty member) and then merged with another
sync per grid -- ~1.5 ms of fixed overhead per member per build, linear in
batch size, which dominates per-iteration ConvolutionPlan construction in
generative training (issue openvdb#755).

BatchedTopologyBuilder.cuh runs each factor-2 refine or coarsen pass over ALL
batch members at once: one emission kernel over every source leaf produces
candidate output leaves as (tile sort key, node key, origin, 512-bit mask)
slots segmented per grid; two stable segmented radix sorts put each grid's
slots in canonical NanoVDB node order (PointsToGrid's offset-shifted tile keys,
then x-major upper/lower child offsets); head-flag + scan passes dedup nodes
and derive per-grid counts and parent linkage; ONE stream synchronization reads
back the node counts; and batched kernels write every grid's headers
(mGridIndex=g, mGridCount=B), nodes, leaf mOffset/mPrefixSum, and bboxes into a
single buffer. Root tiles are derived on-device from the unique upper keys, and
empty members become valid empty grids inline. The mask bit math is NanoVDB's
own refineMask/coarsenMask; the build stages are transcriptions of
TopologyBuilder's functors with (gridIndex, localIndex) indexing. Checksums are
disabled on the output, matching contiguousGridHandle and mergeGridHandles.

fineGridHandleFromCoarseCUDA / coarseGridHandleFromFineCUDA route through the
batched passes for all batch sizes (multi-pass factors chain passes, matching
the previous per-pass semantics); masked subdivision still prunes upstream.
CPU / PrivateUse1 paths and non-power-of-two coordinate fallbacks unchanged.
The header also ships a BoxDilate pass (per-axis bit-shift Minkowski sums with
unit boxes); its conv consumers land in the next commit.

conv_transpose_grid(2,2) at batch 16: 9.0 ms -> 0.80 ms, near-flat in batch
size (23x at batch 48). New equivalence tests pin the canonical node order
elementwise against from_ijk-built topologies and the CPU paths across empty
members, root-tile boundaries, negative octants, and sliced batches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
The stride-1 uniform-K conv/conv-transpose paths ran per-member
DilateGrid/PadGrid loops (perItemGridHandle), and the k3s2 transpose ran a
per-member RefineGrid + negative PadGrid -- the last remaining serial
generated-topology builders on the conv paths after the previous commit.

The canonical supports are Minkowski sums with axis-aligned unit boxes
(odd K: [-1,1]^3 per pass; even K: one-sided {-1,0}^3 / {0,1}^3 passes;
k3s2: refine then {-1,0}^3), so they map directly onto the batched BoxDilate
pass: per (source leaf, target neighbor leaf) slot, the mask contribution is
computed with per-axis bit shifts and deduplicated (mask-OR) by the shared
back-end, all batch members per pass at once. Both perItemGridHandle drivers
and their per-grid merge synchronizations are deleted. Resource-stats
semantics are unchanged (morphology paths still report zero coordinate
staging).

conv_grid(3,1) at batch 16: 9.8 ms -> 0.83 ms (25 ms -> 1.0 ms at batch 48);
the 4-level plan-pyramid rebuild drops 114 ms -> ~18 ms at batch 16.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
…kend

from_grid_batch(1, 1, g) / from_grid_batch_transposed(1, 1, g) rebuild identity
plans every iteration in per-step classifier heads (issue openvdb#755's secondary
item), yet spent ~1.2 ms per call building tensor-valued transform diagnostics
(voxel size/origin metadata tensors, allclose checks, small-tensor .item()
round trips) that are trivially exact when source and target share their
GridBatchData.

Add an identity fast path: K == S == 1 with target_grid=None (conv_grid /
conv_transpose_grid are the identity there, preserving public and data
identity of the generated target) or an explicit target sharing grid data
returns a _MatmulBackend plan directly with exact-by-construction
compatibility diagnostics. Channel-pair validation, unknown-backend rejection,
the pred_gather_igemm path, and the general path for distinct-but-equal grids
(including incompatible-transform errors) are unchanged.

Identity plan construction: ~1.2 ms -> 0.08 ms per call.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
Two per-member host loops taxed every grid construction and plan validation
(issue openvdb#755):

- makeGridBatchData built leafBatchIndices with one torch::full per batch
  member plus a torch::cat (B+1 kernel dispatches per grid build). Leaf counts
  are already host-side, so one repeat_interleave over a device arange does the
  same in two dispatches.
- voxelSizesTensor / voxelOriginsTensor filled their [B,3] metadata tensors
  with per-element ATen indexing (6 dispatches per grid per call, on every
  ConvolutionPlan transform validation). A CPU accessor fill removes the
  dispatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Jonathan Swartz <jonathan@jswartz.info>
@swahtz
swahtz requested a review from a team as a code owner September 1, 2026 09:13
@swahtz swahtz added the optimization Performance or memory optimization label Sep 1, 2026
@swahtz
swahtz requested a review from harrism September 1, 2026 09:13
@swahtz swahtz added the core library Core fVDB library. i.e. anything in the _Cpp module (C++) or fvdb python module label Sep 1, 2026
@swahtz
swahtz requested a review from fwilliams September 1, 2026 09:13
@swahtz swahtz added the Topology Operations Issues related to topology operations (prune, merge, dilate, etc. label Sep 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core library Core fVDB library. i.e. anything in the _Cpp module (C++) or fvdb python module optimization Performance or memory optimization Topology Operations Issues related to topology operations (prune, merge, dilate, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generated-topology grid construction dominates per-iteration ConvolutionPlan cost (serial per-grid build + merge)

1 participant